Skip to content

Export backup on a background task with in-app progress#92

Merged
kyleve merged 3 commits into
mainfrom
background-export-progress
Jul 15, 2026
Merged

Export backup on a background task with in-app progress#92
kyleve merged 3 commits into
mainfrom
background-export-progress

Conversation

@kyleve

@kyleve kyleve commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Exporting a backup from Settings felt like a freeze on a large database. The export CPU/IO already ran off the main thread (SwiftDataStore is a @ModelActor; the zip is a Task.detached(priority: .utility)), so the freeze was really the system share sheet's modal "Preparing…" phase: the old ShareLink used a lazily-built Transferable, so tapping Export presented the share sheet immediately and left it blocking in "Preparing…" while the archive built.

This builds the archive up-front on a background task, streaming progress to an in-app "Exporting…" bar (mirroring import), then reveals a ShareLink to the ready file so the share sheet opens instantly.

Changes

  • BackupCoordinator.exportBackup gains an onProgress callback: the evidence-blob load drives the determinate leg (0…0.8, whole-percent throttled like import), then jumps to 1 once the opaque encode + zip writes the archive.
  • BackupModel.exportBackup marshals that progress onto backupProgress via the same AsyncStream + @MainActor observer pattern as importBackup.
  • SettingsView replaces the lazy ShareLink with an Export button (showing the progress label while exporting) plus a revealed "Share backup" ShareLink over the ready file. importProgressLabel is generalized to backupProgressLabel(_:systemImage:), reused by both rows.
  • BackupArchiveFile is now a non-lazy Transferable wrapping the ready URL, so the .zip content type stays explicitly declared rather than inferred from the filename.
  • 10-minute expiry: a .task(id: exportedArchiveURL) hides the "Share backup" row and deletes the temp file after a retention window, so a stale link can't be shared. Deletion routes through the new BackupCoordinator.discardExport() (which owns the temp-file lifecycle); BackupModel.discardExport() forwards to it.
  • Adds settings.backup.exporting ("Exporting…") and settings.backup.share ("Share backup") strings.

Notes / trade-offs

  • Progress is only truly determinate over the evidence-blob load; the JSON-encode + zip is a single atomic step, so the bar holds near 0.8 then completes — inherent to one opaque zip call, documented in code.
  • Stays ShareLink-based (one extra tap: Export → then Share) rather than a UIActivityViewController bridge. As a consequence, "hide when the share sheet closes" isn't wired — ShareLink exposes no dismissal callback — so the temp file is reclaimed by the 10-minute expiry (or the next export) instead.

Testing

  • BackupCoordinatorTests.exportReportsProgressUpToCompletion — progress is non-empty, in (0, 1], and ends at 1.
  • BackupCoordinatorTests.discardExportDeletesTheExportDirectory — the temp directory is gone after discardExport, and a second call is an idempotent no-op.
  • Existing BackupModelTests / backup round-trip tests still pass.
  • ./swiftformat --lint clean; WhereCoreTests and WhereUITests pass locally.

Made with Cursor

kyleve and others added 3 commits July 15, 2026 16:09
Building the backup archive was driven by a ShareLink over a lazily-built
Transferable, so tapping Export presented the share sheet immediately and
left it in the system's blocking 'Preparing…' state while the (already
off-main) encode + zip ran — reading as a freeze on a large database.

Build the archive up-front on a background task instead, streaming progress
to an in-app 'Exporting…' bar (mirroring import), then reveal a ShareLink to
the ready file so the share sheet opens instantly.

- BackupCoordinator.exportBackup gains an onProgress callback: the
  evidence-blob load drives the determinate leg (0…0.8, whole-percent
  throttled like import), then jumps to 1 once the opaque encode+zip writes
  the archive.
- BackupModel.exportBackup marshals that progress onto backupProgress via
  the same AsyncStream + @mainactor observer pattern as importBackup.
- SettingsView replaces the lazy ShareLink with an Export button (showing the
  progress label while exporting) plus a revealed 'Share backup' ShareLink
  over the ready URL; importProgressLabel is generalized to backupProgressLabel.
- Remove the now-unused BackupArchiveFile; add exporting/share strings.

Covered by BackupCoordinatorTests.exportReportsProgressUpToCompletion.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Remove the unreachable evidence.isEmpty guard in exportBackup's progress
  loop (the loop body only runs when evidence is non-empty, so count >= 1).
- Reintroduce BackupArchiveFile as a non-lazy Transferable wrapping the
  ready archive URL, so ShareLink declares an explicit .zip content type
  instead of inferring it from the filename extension.

Co-authored-by: Cursor <cursoragent@cursor.com>
A finished export left the 'Share backup' row (and its temp .zip) around
indefinitely, so the link could go stale if the OS reclaimed the temp file.

Auto-discard it after a retention window: a .task(id: exportedArchiveURL)
sleeps 10 minutes, then hides the share row and deletes the temp file. The
deletion routes through the coordinator, which owns the temp-file lifecycle,
via a new BackupCoordinator.discardExport() (BackupModel.discardExport()
forwards to it). Hiding before deleting closes the stale-tap window.

Per the chosen direction this stays ShareLink-based, so hide-on-share-sheet-
close isn't wired (ShareLink exposes no dismissal callback).

Covered by BackupCoordinatorTests.discardExportDeletesTheExportDirectory.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kyleve kyleve enabled auto-merge (squash) July 15, 2026 23:35
@kyleve kyleve merged commit 6850046 into main Jul 15, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant